有製作過遊戲的朋友,肯定有過這樣的經驗,明明計算好這一關最高只可能得到300分,卻總是有人可以一下突破天際得到30000分。而這個情況不是只發生在網頁遊戲,電腦上或任何主機上的遊戲也都有類似的作弊軟硬體,可以偵測遊戲中儲存數值的記憶體位置,並加以修改。
這種作弊軟體的操作方法,通常需要玩家先找到遊戲中的某個數字,比如說目前的血量、目前的金幣、目前的經驗值等等。假設這位玩家想要獲得更多金幣,那麼他就要告訴作弊軟體,目前的金幣量,假設是100。
接下來玩家要進行個一兩次的戰鬥,讓金幣量稍微增加一些。假設戰鬥過後的金幣增加到了120,接著再請作弊軟體去記憶體內搜尋,看看哪一塊記憶體上的數字由先前的100變成了現在的120。如果找到的記憶體位置不只一個,那就再戰鬥個兩場讓金幣量再次改變,並請作弊軟體接著搜尋,直到某一塊記憶體位置上的數字,完全照著玩家發現的數字變化在改變。
找到儲存金幣的記憶體位置後,就可以靠著作弊軟體任意調整記憶體上儲存的數字,玩家立馬變成大富翁,商店裏最強的傳說武器和超貴的復活藥水都被搜括一空。但很快地,這個遊戲也就會被玩家給廢了,而且可能還會在論壇中表示這遊戲太簡單,三小時全破。
因為作弊是很多玩家的天性,所以保護好遊戲內的數字就變成一項必要的工作。將數字保護起來的方法,可以分成兩個部分:
第一項工作的實作方法,就是在設定數值的時候,將數值以Hash函式產生一個檢查碼。在之後檢查時,把數值再次Hash,看看新的Hash和舊的是否吻合。這個Hash函式的效率必須要高,因為這項檢查工作可能每幀都會做個幾百次。
Hash函式可以將資料利用複雜的計算式轉換成另一組資料,這個轉換的結果有兩個特性:
- 抗碰撞性:不一樣的資料轉換後也會不一樣。
- 不可逆性:轉換後的資料無法轉換回原本的資料。
忘記什麼是Hash的同學可以回到《Trick 15: 把Hash函數帶進遊戲玩》複習一下。
第二項工作是在發現數字被篡改後,要將數字復原。這項工作的要點則是要把數字藏起來,並且保證被藏起來的數字備份不會在玩家作弊時一起被篡改。
寫程式時,要先決定採用哪兩個技術來對應數字保安的兩個工作,檢查與復原。在把數字保全起來的類別中,每次取值出來用的時候,就要先比對一下hash是否保持相同,如果發現hash不同,我們就要先將數值還原,再把還原後的值傳出去。
在實作的時候,我們要先寫以下三個函式,定義Hash和備份用的演算法。
// 用value來產生hash
function getHash(value: number): string|number {
let hash = ...
return hash;
}
// 將value藏在backup裏
function getBackup(value: number): string {
let backup = ...
return backup;
}
// 將backup還原成value
function restoreValue(backup: string): number {
let value = ...
return value;
}
有了Hash、備份和還原三個函式,就能把數字保安的類別寫出來了。
// 定義一個數字保安類別
class SecureNumber {
// 用來檢查數字篡改的hash
hash: string|number;
// 用來還原數字的資料
backup: string;
/**
* 建構子,要給一個需要保安的數字
* 我們會用一個隱私的_value屬性來儲存
*/
constructor(private _value: number) {
// 產生hash
this.hash = getHash(_value);
// 建立數字還原資料
this.backup = getBackup(_value);
}
// 取值
getValue(): number {
// 用現在的_value產生目前應有的hash
let hash = getHash(this._value);
// 檢查目前的hash和之前的是否吻合
if(hash != this.hash) {
// hash不合,就要復原值
this._value = restoreValue(this.backup);
}
return this._value;
}
// 更新值
setValue(value: number) {
// 先取得目前的的value
let currentValue = this.getValue();
// 如果新的值不同才要設定
if(currentValue != value) {
this._value = value;
// 更新hash
this.hash = getHash(value);
// 更新還原資料
this.backup = getBackup(value);
}
}
}
現在我們只要決定getHash(value)、getBackup(value)以及restoreValue(backup)這三個保安函式要怎麼寫,這個SecureNumber類別就可以拿來用了。
其實不只數字需要保護,遊戲中有些重要的字串、布林值,甚至一整個Json都可以用這種方法保護起來。
先提供同學們一組簡易版的保安函式,用同一個方法加密與備份,方便快速理解與測試。
加密時,先將原資料加鹽(salt),再轉成一個隨機進位的字串。這個方法的好處是運算速度快,缺點是只能處理整數。
// 先隨機製造一把鹽(salt), -5000~5000
let salt = Math.round((Math.random() - 0.5) * 10000);
// 隨機選一個進位(16~36)
let carry = 16 + Math.round(Math.random() * 20);
// 用value來產生hash
function getHash(value: number): string {
// 將值加上salt,然後變成carry進位的字串
let hash = (value + salt).toString(carry);
return hash;
}
// 將value藏在backup裏
function getBackup(value: number): string {
// 用同樣的方法來藏value
let backup = getHash(value);
return backup;
}
// 將backup還原成value
function restoreValue(backup: string): number {
// 先把backup用carry進位轉回十進位的數字,然後減去salt
let value = parseInt(backup, carry) - salt;
return value;
}
如果我們進行一次如下的實驗,
let value = 100;
let backup = getBackup(value);
console.log('salt = ' + salt);
console.log('carry = ' + carry);
console.log('backup = ' + backup);
console.log('restore = ' + restoreValue(backup));
會得到這樣的結果:|
:---------|:---------salt = 4823
|亂數鹽carry = 19
|隨機選的進位數backup = dc2
|原值被轉換後的備份字串restore = 100
|從備份字串轉回來的原值
如此一來,玩家即使找得到儲存100這個數字的記憶體位置,但是他再怎麼改來改去,遊戲也會一直把改過的值還原。諒他猜也猜不到,放dc2
的記憶體才是真正我們儲存值的地方。
這邊列出些常用的Hash函式,以及加密工具,同學可以自行選擇適合的來用。
在今天的示範程式中,使用CRC32為Hash函式,並使用Base64為加密函式。
// 這是CG給的亂數產生工具
let rng = CG.Base.utils.systemRandomGenerator;
// 產生8個字元長度的隨機字串鹽(salt)
let salt = rng.generateRandomString(8);
// 產生另一個隨機字串鹽(salt)給backup用
let backupSalt = rng.generateRandomString(8);
// 用value來產生hash
function getHash(value: number): string | number {
// 使用CG工具來進行CRC32的Hash運算
let hash = StringUtil.crc32(value + salt)
return hash;
}
// 將value藏在backup裏
function getBackup(value: number): string {
// btoa()是一個把字串變成base64碼的系統函式
let backup = btoa(value + backupSalt);
return backup;
}
// 將backup還原成value
function restoreValue(backup: string): number {
// atob()是一個把base64碼還原成原始字串的系統函式
let value = atob(backup);
// 去掉value最後面的backupSalt
value = value.substring(0, value.length - backupSalt.length);
// 將字串轉回數字
return Number(value);
}
同樣地,我們也來做一次作弊的實驗。
let originalValue = 100;
console.log("原始值 = " + originalValue);
// 建立數字保安
let num = new SecureNumber(originalValue);
console.log("數字保安已建立:");
console.log(" Hash = " + num.hash);
console.log(" Backup = " + num.backup);
// 篡改數字為3000, 使用['']可以手動改變隱私屬性(建議少用)
num['_value'] = 3000;
console.log("數字已被篡改為 " + num['_value']);
console.log("getValue() = " + num.getValue());
然後得到這樣的結果原始值 = 100
數字保安已建立:
Hash = 1226020697
Backup = MTAwTkNBR1lXWEk=
數字已被篡改為 3000
getValue() = 100
在數字被篡改後,我們再使用num.getValue()還是可以得到原始設定的100。
CG示範專案
專案有使用到StringUtil裏的crc32()函式,原始碼: StringUtil.ts
最後我們用同樣的方法來實作一個保護JSON的類別,讓同學們知道不論什麼樣的資料型態都可以用這樣的方法加以保護。
首先改一下用來加密解密的函式。
let rng = CG.Base.utils.systemRandomGenerator;
// 產生8個字元長度的隨機字串鹽(salt)
let salt = rng.generateRandomString(8);
// 產生另一個隨機字串鹽(salt)給backup用
let backupSalt = rng.generateRandomString(8);
// 用value來產生hash
function getHash(value: string): string | number {
let hash = StringUtil.crc32(value + salt)
return hash;
}
// 將value藏在backup裏
function getBackup(value: string): string {
// btoa()是一個把字串變成base64碼的系統函式
let backup = btoa(value + backupSalt);
return backup;
}
// 將backup還原成value
function restoreValue(backup: string): {[key: string]: any} {
// atob()是一個把base64碼還原成原始字串的系統函式
let value = atob(backup);
// 去掉value最後面的backupSalt
value = value.substring(0, value.length - backupSalt.length);
return value;
}
然後來寫Json的保安類別
class SecureJson {
// 用來檢查有無被篡改的hash
hash: string | number;
// 用來還原Json的資料
backup: string;
/**
* 建構子,要給一個需要保安的數字
* 我們會用一個隱私的_value屬性來儲存
*/
constructor(private _data: {[key: string]: any} = {}) {
let json = JSON.stringify(_data);
// 產生hash
this.hash = getHash(json);
// 建立還原資料
this.backup = getBackup(json);
}
// 取值的函式
getValue(key: string): any {
// 再用現在的_data產生一次hash
let json = JSON.stringify(this._data);
let hash = getHash(json);
// 檢查目前的hash和之前的是否吻合
if (hash != this.hash) {
// hash不合,就要復原Json
this._data = JSON.parse(restoreValue(this.backup));
}
return this._data[key];
}
// 更新值的函式
setValue(key: string, value: any) {
// 先取得目前的的value
let currentValue = this.getValue(key);
// 如果新的值不同才要設定
if (currentValue !== value) {
this._data[key] = value;
// 更新hash
let json = JSON.stringify(this._data);
this.hash = getHash(json);
// 更新還原資料
this.backup = getBackup(json);
}
}
}
系統機制的公平,影響著玩家在心理層面對一個遊戲的定義。如果是即時連線的競技遊戲,或是遊戲中有分數比較、團體對抗等離線的排行榜,那麼玩家作弊就是不可容忍之惡,需要儘一切力量去防堵。
但如果是RPG、解謎等單人遊戲,那麼留著一些後門讓玩家去胡搞瞎搞,也可能增添遊戲設計以外的樂趣與談資。
在遊戲釋出之後,觀察玩家的生態,時不時進場攪亂一池春水,就是我們遊戲設計師最大的樂趣。